iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
生成式 AI

iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線系列 第 16

Day 16 - 【鬧鐘實戰 VI】畫龍點睛:串接系統通知與 UX 優化

  • 分享至 

  • xImage
  •  

昨天,我們完成了所有鬧鐘的細節設定,App 在功能層面幾乎已經完整了。使用者可以新增、編輯、刪除鬧鐘,並為其設定重複和提示聲。但有一個最根本的問題:它還不會「叫」!

一個不會在指定時間提醒你的鬧鐘,就只是一個資料庫管理器。今天,我們就要來解決這些問題,為我們的鬧鐘 App 真正地「畫龍點睛」。

  • 困惑點:如何在 iOS 中實現本地通知讓鬧鐘準時響起?管理這些通知的邏輯(新增、取消)應該放在哪裡才不會讓程式碼變亂?

  • AI 協作

    • 我的 Prompt:我的鬧鐘 App 功能已接近完成,現在需要實現最重要的『通知』功能,讓鬧鐘能在指定時間提醒使用者。請問在 iOS 中如何做到?另外,管理通知的程式碼似乎很複雜,有沒有推薦的架構模式?最後,我想提升 App 的整體質感,例如統一導航列顏色和自動處理過期鬧鐘,有什麼建議嗎?請簡單說明並不用給程式碼

    AI 的回答:

    在 iOS 中要實現鬧鐘通知功能,主要會使用 User Notifications 框架。這個框架能讓你的應用程式在前景或背景模式下,於指定時間點發送本地通知(Local Notifications)。


    如何在 iOS 中實現通知功能

    通知的運作流程基本上分為三個主要步驟:

    1. 請求使用者授權: 這是第一步,你的 App 需要先向使用者請求發送通知的權限。這是 iOS 為了保護使用者隱私而設計的機制,使用者可以選擇同意或拒絕。
    2. 建立通知內容: 你需要建立一個 UNMutableNotificationContent 物件,來定義通知的內容,例如標題(title)、副標題(subtitle)、本文(body)以及音效(sound)等。
    3. 安排通知發送: 接著,你需要使用 UNCalendarNotificationTriggerUNTimeIntervalNotificationTrigger 等觸發器,設定通知的發送時間。例如,鬧鐘 App 就會使用 UNCalendarNotificationTrigger 搭配指定的日期和時間。最後,將內容與觸發器打包成一個 UNNotificationRequest,並透過 UNUserNotificationCenter 將其排程,等待發送。

    這三個步驟是實現通知功能的核心,缺一不可。


    管理通知的架構模式

    你提到管理通知的程式碼可能很複雜,這確實是許多開發者會遇到的問題。為了讓程式碼更清晰、易於維護,我建議採用 單例模式(Singleton Pattern) 來管理你的通知邏輯。

    單例模式的優點在於,它能確保在整個 App 的生命週期中,只有一個實例(instance)負責處理所有與通知相關的操作。你可以將所有關於通知的程式碼,如請求授權、排程通知、取消通知等,都封裝在這個單例類別中。

    這麼做的好處是:

    • 集中管理: 所有的通知邏輯都在同一個地方,當你需要新增、修改或刪除通知時,只需要操作這個單例。
    • 避免重複程式碼: 任何需要使用通知功能的頁面,都可以直接呼叫這個單例的公共方法,避免在不同地方寫重複的程式碼。
    • 提高可維護性: 如果未來 iOS 的通知框架有更新,你只需要修改單例類別的內部邏輯,而不需要動到其他與通知功能相關的頁面。

    提升 App 整體質感的建議

    除了核心功能外,提升使用者體驗和 App 的質感也很重要。以下是一些你可以考慮的建議:

    1. 統一導航列(Navigation Bar)顏色: 導航列是 App 中非常顯眼的部分。在 AppDelegateSceneDelegatedidFinishLaunchingWithOptions 方法中,透過 UINavigationBar.appearance() 設置導航列的統一外觀,可以確保整個 App 的風格一致。例如,你可以設定背景顏色、標題文字顏色和按鈕顏色,讓 App 看起來更專業。
    2. 自動處理過期鬧鐘: 鬧鐘 App 的一個常見問題是,使用者可能不會手動刪除已經響過的鬧鐘。為了保持清爽的使用者介面,你可以設計一個機制,在鬧鐘被觸發或過了指定時間後,自動將其從清單中移除。你可以使用 背景處理(Background Tasks) 或是在每次 App 啟動時檢查並清理過期鬧鐘。
    3. 流暢的動畫和轉場: 在不同頁面切換或顯示彈出視窗時,使用自訂的動畫可以讓 App 感覺更流暢、更具現代感。即使只是簡單的淡入淡出效果,也能讓使用者體驗大幅提升。

    這些建議能在不增加太多複雜度的情況下,有效提升你 App 的專業度和使用者滿意度。

    如果你對上述的任何一個部分,例如單例模式的具體實現或是如何設定導航列的外觀有更深入的疑問,隨時可以提出,我會提供更詳細的說明。

    • 根據 AI 的建議,我把詳細的實作步驟整理如下:
      1. 請求通知權限:在 App 啟動時,第一時間向使用者請求權限。
      2. 建立通知大腦 (NotificationManager):使用單例模式,建立一個專門管理所有通知排程與取消的類別。
      3. 全面整合 NotificationManager:在新增、更新、刪除鬧鐘的地方,都呼叫 NotificationManager 的對應方法。
      4. 同步開關狀態與通知:讓主畫面的鬧鐘開關,也能觸發通知的排程與取消。
      5. 實現過期鬧鐘自動關閉:監聽 App 返回前景事件,自動處理已失效的單次鬧鐘。
      6. 實現 App 的主題顏色:撰寫一個可共用的靜態方法,統一所有導航列的視覺風格。

步驟一:請求通知權限

在 iOS 中,任何 App 要發送通知前,都必須明確地獲得使用者的同意。我打開 MainViewController.swift,引入 UserNotifications 框架,並在 viewDidLoad 中呼叫權限請求。

檔案:MainViewController.swift

import UserNotifications // 引入框架

// In MainViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    requestNotificationPermission() // <<-- 新增此行
    // ... 其他 setup 方法
}

// ...
func requestNotificationPermission() {
    UNUserNotificationCenter.current().requestAuthorization(
        options: [.alert, .sound, .badge] // 請求提示、聲音、圖標標記的權限
    ) { (granted, error) in
        if granted {
            print("使用者已同意通知權限")
        } else {
            print("使用者已拒絕通知權限")
        }
    }
}

步驟二:建立通知大腦 (NotificationManager)

為了集中管理所有與通知相關的複雜邏輯,我採納了 AI 的建議,使用單例模式建立了一個 NotificationManager

檔案:NotificationManager.swift

import Foundation
import UserNotifications

class NotificationManager {
    
    // 透過靜態常數建立一個全局唯一的實例
    static let shared = NotificationManager()
    private let center = UNUserNotificationCenter.current()
    
    // 將初始化方法設為私有,防止外部建立新實例
    private init() {}
    
    /// 為指定的鬧鐘排程通知
    func scheduleNotification(for alarm: AlarmData) {
        let content = UNMutableNotificationContent()
        content.title = "鬧鐘"
        content.body = alarm.name.isEmpty ? "時間到了!" : alarm.name
        content.sound = UNNotificationSound(named: UNNotificationSoundName("\(alarm.sound).mp3"))

        guard let date = formatStringToDate(alarm.alarmTime) else { return }
        let dateComponents = Calendar.current.dateComponents([.hour, .minute], from: date)
        
        let repeatDays = Array(alarm.repeatDays)
        // 檢查是否有設定重複
        if repeatDays.contains(true) {
            // 為每一個勾選的重複日建立一個獨立的、會重複的通知
            for (index, shouldRepeat) in repeatDays.enumerated() where shouldRepeat {
                var triggerComponents = dateComponents
                triggerComponents.weekday = index + 1 // 1=週日, 2=週一...
                let trigger = UNCalendarNotificationTrigger(dateMatching: triggerComponents, repeats: true)
                // 使用 "鬧鐘ID_星期索引" 作為唯一標識符
                let request = UNNotificationRequest(identifier: "\(alarm.id)_\(index)", content: content, trigger: trigger)
                center.add(request)
            }
        } else {
            // 建立一個單次的、不重複的通知
            let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false)
            let request = UNNotificationRequest(identifier: alarm.id, content: content, trigger: trigger)
            center.add(request)
        }
    }
    
    /// 取消指定鬧鐘的所有通知
    func cancelNotification(for alarm: AlarmData) {
        var identifiers: [String] = [alarm.id] // 加入單次鬧鐘的 ID
        // 加入所有可能的重複鬧鐘 ID
        for i in 0..<7 {
            identifiers.append("\(alarm.id)_\(i)")
        }
        center.removePendingNotificationRequests(withIdentifiers: identifiers)
    }
    
    private func formatStringToDate(_ timeString: String) -> Date? {
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm"
        return formatter.date(from: timeString)
    }
}

步驟三 & 四:全面整合並同步開關狀態

有了 NotificationManager 這個大腦,接下來就是在所有會影響鬧鐘狀態的地方呼叫它。

  • AddAlarmViewController:在 saveNewAlarm()updateAlarm(_:) 方法中,加入了排程和取消通知的呼叫。

    檔案:AddAlarmViewController.swift

    // 在 AddAlarmViewController.swift -> saveNewAlarm(_:)
    func saveNewAlarm(){
    		//其他設定
    		NotificationManager.shared.scheduleNotification(for: newAlarm)
    		//通知delegate並關閉頁面
    }
    
    // 在 AddAlarmViewController.swift -> updateAlarm(_:)
    func updateAlarm(_ alarm: AlarmData) {
        NotificationManager.shared.cancelNotification(for: alarm) // <<-- 先取消舊的
        // ... 更新資料 ...
        NotificationManager.shared.scheduleNotification(for: alarm) // <<-- 再排程新的
        // ...
    }
    
  • MainViewController:在 deleteAlarm(_:) 中加入了取消通知的呼叫。

    檔案:MainViewController.swift

    // 在 MainViewController.swift -> alarmSwitchChanged(_:)
    @objc func alarmSwitchChanged(_ sender: UISwitch) {
        NotificationManager.shared.scheduleNotification(for: alarm)
    
        // ... 更新資料庫 ...
        // ... 更新 Cell 外觀 ...
    }
    
  • 同步開關:最關鍵的是,我在 MainViewControlleralarmSwitchChanged(_:) 方法中加入了判斷邏輯:如果開關被打開,就排程通知;如果被關閉,就取消通知。

步驟五:實現過期鬧鐘自動關閉

我採納了 AI 的建議,在 MainViewController 中監聽 UIApplication.didBecomeActiveNotification,並在觸發時呼叫 checkAndDisablePastOneTimeAlarms() 方法,自動將已過期的單次鬧鐘關閉。

檔案:MainViewController.swift


override func viewDidLoad() {
    super.viewDidLoad()
    // ...
    setupNotificationObserver() // <<-- 新增
    checkAndDisablePastOneTimeAlarms() // <<-- 新增
}

func setupNotificationObserver() {
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(applicationDidBecomeActive),
        name: UIApplication.didBecomeActiveNotification,
        object: nil
    )
}

@objc func applicationDidBecomeActive() {
    checkAndDisablePastOneTimeAlarms()
}

func checkAndDisablePastOneTimeAlarms() {
    // ... 檢查並關閉過期鬧鐘的邏輯 ...
}

步驟六:實現 App 的主題顏色

最後,為了讓 App 的視覺風格統一,我採納了 AI 關於 DRY 原則的建議,在 MainViewController 中建立了一個 static 的工具方法 setupOrangeNavigationBarAppearance,將所有關於導航列外觀的程式碼都封裝在裡面。

檔案:MainViewController.swift

/// 設定橘色導航列外觀的靜態方法
static func setupOrangeNavigationBarAppearance(for navigationController: UINavigationController?) {
    guard let navigationBar = navigationController?.navigationBar else { return }
   
    // 1. 建立一個新的外觀設定物件
    let appearance = UINavigationBarAppearance()
    
    // 2. 設定背景為透明,這樣我們才能自訂顏色
    appearance.configureWithTransparentBackground()
    
    let orangeColor = UIColor.systemOrange
    
    // 3. 設定大標題和小標題的文字顏色
    appearance.largeTitleTextAttributes = [.foregroundColor: orangeColor]
    appearance.titleTextAttributes = [.foregroundColor: orangeColor]
    
    // 4. 設定所有按鈕 (BarButtonItem) 的文字顏色
    let buttonAppearance = UIBarButtonItemAppearance()
    buttonAppearance.normal.titleTextAttributes = [.foregroundColor: orangeColor]
    appearance.buttonAppearance = buttonAppearance
    appearance.doneButtonAppearance = buttonAppearance // 「完成」按鈕也使用相同外觀
    
    // 5. 將設定好的外觀應用到導航列的不同狀態上
    navigationBar.standardAppearance = appearance // 標準尺寸
    navigationBar.scrollEdgeAppearance = appearance // 捲動到邊緣時
    navigationBar.compactAppearance = appearance // 緊湊尺寸
    
    // 6. 設定按鈕圖示的顏色
    navigationBar.tintColor = orangeColor
}

現在,我們有了這個強大的工具函式,就可以去改造各個頁面的 UI 設定了。

  1. 改造 MainViewController

    • 找到 setupNavigationBar() 方法。
    • 在方法的最開頭,呼叫我們剛剛建立的靜態函式。

    檔案:MainViewController.swift

    func setupNavigationBar() {
        // 【新增】在設定按鈕前,先套用橘色主題
        MainViewController.setupOrangeNavigationBarAppearance(for: self.navigationController)
    
        title = "鬧鐘"
        navigationController?.navigationBar.prefersLargeTitles = true
    
        // ... 設定左右按鈕的程式碼維持不變 ...
    }
    
  2. 改造 AddAlarmViewController

    • 打開AddAlarmViewController.swift
    • 找到 setupUI() 方法。
    • 在方法的最開頭,同樣呼叫這個靜態函式。

    檔案:AddAlarmViewController.swift

    func setupUI() {
        // 【新增】套用橘色主題
        MainViewController.setupOrangeNavigationBarAppearance(for: self.navigationController)
    
        if alarmToEdit == nil {
            title = "加入鬧鐘"
        } else {
            title = "編輯鬧鐘"
        }
    
        // ... 設定左右按鈕的程式碼維持不變 ...
    }
    

最終驗證

恭喜!我們的鬧鐘 App 現在功能完全了!運行 App:

  • 首次啟動時,它會向您請求通知權限。
  • 設定一個一分鐘後的鬧鐘,回到主頁,您應該會準時收到通知!
  • 關閉鬧鐘開關,通知將被取消。
  • 設定一個過去時間的「單次」鬧鐘,關閉 App 再重新打開,它的開關會被自動關閉。
  • App 內所有頁面的導航列都統一變成了漂亮的橘色主題!
    Simulator Screen Recording - iPhone 16 Pro - 2025-09-27 at 11.01.07

「鬧鐘實戰」系列到此告一段落。我們從零到有,打造了一個功能完整、架構合理、體驗優良的 App。明天,我們將短暫地停下腳步,回顧一下我們所寫的程式碼,聊聊當 MVC 架構遇上瓶頸時,該如何用 MVVM 來拯救它。


上一篇
Day 15 - 【鬧鐘實戰 VI】細節打磨:實現「重複」與「提示聲」設定
下一篇
Day 17 - 程式碼健檢:當 MVC 遇上瓶頸,用 MVVM 來拯救你的 ViewController
系列文
iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言